import { EventEmitter } from 'events'
import { existsSync } from 'fs-extra'
import MagicString from 'magic-string'
import path from 'path'
import { Plugin, PluginContext } from 'rollup'
import { name as pkgName } from '../package.json'

const noop = () => void 0

const normalizePath = (p: string) => {
  return path.posix.normalize(p).replace(/\\/g, '/')
}

const escape = (str: string) => str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')

const ensureFunction = (functionOrValue: string | ((id: string) => string)) => {
  if (typeof functionOrValue === 'function') return functionOrValue
  return () => functionOrValue
}

const longest = (a: string, b: string) => b.length - a.length

const mapToFunctions = (object: Record<string, string | ((id: string) => string)>) => {
  return Object.keys(object).reduce((fns, key) => {
    const functions = Object.assign({}, fns)
    functions[key] = ensureFunction(object[key])
    return functions
  }, {} as Record<string, (id: string) => string>)
}

export default function hotModuleCommonJS({
  srcRoot,
  disabled,
  extraChunk,
}: {
  srcRoot: string
  disabled?: boolean
  extraChunk?: (this: PluginContext, hotModuleId: string) => string | Promise<string>
}): Plugin {
  const _disabledReplacementEntries = Object.entries(EventEmitter.prototype)
    .concat([
      ['data', {}],
      ['preventReload', noop],
      ['isPreventReload', noop],
      ['isObsolete', noop],
      ['watch', noop],
    ])
    .map(([key, value]) => {
      return [
        key,
        typeof value === 'function'
          ? 'void function(){}'
          : typeof value === 'object'
          ? 'Object()'
          : 'undefined',
      ]
    })

  const replacements = Object.fromEntries(
    disabled
      ? [
          ..._disabledReplacementEntries.map(([key, value]) => [`module.hot.${key}`, value]),
          ..._disabledReplacementEntries.map(([key, value]) => [`import.meta.hot.${key}`, value]),
          ['module.hot', 'undefined'],
          ['import.meta.hot', 'undefined'],
          ['import.meta.hot.', 'module.hot.'],
        ]
      : [
          ['import.meta.hot.', 'module.hot.'],
          ['import.meta.hot', 'module.hot'],
        ]
  )

  let sourcemap: boolean = true
  let isInit = true
  const sourceRoot = normalizePath(path.resolve(srcRoot))

  const functionValues = mapToFunctions(replacements)
  const keys = Object.keys(functionValues).sort(longest).map(escape)
  const pattern = new RegExp(
    `((?:^|[^\\.'"\`])\\s*\\b)(${keys.join('|')})\\b\\s*(?!\\s*=[^=])([^\\.\\['"\`\\s]|$)`,
    'g'
  )

  const executeReplacement = (code: string, id: string, magicString: MagicString) => {
    for (const match of code.matchAll(pattern)) {
      if (match.index == null) continue
      const start = match.index! + match[1].length
      const end = start + match[2].length
      const replacement = String(functionValues[match[2]](id))
      magicString.overwrite(start, end, replacement)
    }
  }

  let hotModuleId: string

  const executePrependHotCode = (
    code: string,
    id: string,
    margicString: MagicString,
    context: PluginContext
  ) => {
    if (/(^_virtual|\bnode_modules)\b/.test(id)) return
    const hotModuleFilename = context.getFileName(hotModuleId)
    if (id === hotModuleFilename || path.isAbsolute(id)) return

    let start = 0
    let match
    if ((match = code.match(/^(?:[\s\n]*(?<q>['"])[^'"\n\r]+\k<q>[\s\n]*;?)+/))) {
      start = (match.index ?? 0) + match[0].length
    }

    const dirname = path.resolve(path.dirname(id))
    const hotId = './' + normalizePath(path.relative(dirname, path.resolve(hotModuleFilename)))

    margicString.appendLeft(
      start,
      `\n/*@__PURE__*/({require,module}=((0,require)(${JSON.stringify(
        hotId
      )})).hotModule(module));\n`
    )
  }

  let fileContents = new Map<string, any>()

  const hotModuleChunkId = '\0virtual:hot-module-cjs'

  return {
    name: 'hot-module-cjs',
    buildStart(options) {
      if (!disabled) {
        hotModuleId = this.emitFile({
          type: 'chunk',
          id: hotModuleChunkId,
        })
      }
    },
    watchChange(id, { event }) {
      if (event === 'delete') {
        fileContents.delete(id)
      }
    },
    resolveId(id) {
      if (id === hotModuleChunkId) return hotModuleChunkId
      return null
    },
    load(id) {
      if (id === hotModuleChunkId) {
        return `
import * as hotModule from ${JSON.stringify(pkgName)};
export * from ${JSON.stringify(pkgName)};
${extraChunk?.call(this, 'hotModule') ?? ';'}
`
      }
      return null
    },
    outputOptions(options) {
      if (!disabled) {
        options.preserveModules = true
        options.preserveModulesRoot = sourceRoot
        return undefined
      }
    },
    transform(code, id) {
      const magicString = new MagicString(code)
      executeReplacement(code, id, magicString)

      return {
        code: magicString.toString(),
        map: sourcemap ? magicString.generateMap({ hires: true }) : undefined,
      }
    },
    renderStart(options) {
      sourcemap = !!options.sourcemap
    },
    renderChunk(code, chunk) {
      const magicString = new MagicString(code)
      const id: string = chunk.fileName

      if (!disabled) {
        executePrependHotCode(code, id, magicString, this)
      }

      return {
        code: magicString.toString(),
        map: sourcemap ? magicString.generateMap({ hires: true }) : undefined,
      }
    },
    generateBundle(options, bundle) {
      if (!disabled) {
        if (isInit) {
          for (const key of Object.keys(bundle)) {
            const currentFile = bundle[key]

            if (currentFile.type === 'chunk') {
              fileContents.set(key, currentFile.code)
            } else {
              fileContents.set(key, currentFile.source)
            }
          }
        } else {
          for (const key of Object.keys(bundle)) {
            const currentFile = bundle[key]
            const oldSrc = fileContents.get(key)

            if (currentFile.type === 'chunk') {
              if (oldSrc === currentFile.code) {
                delete bundle[key]
              } else {
                fileContents.set(key, currentFile.code)
              }
            } else if (currentFile.type === 'asset') {
              if (
                oldSrc === currentFile.source ||
                oldSrc?.toString() === currentFile.source.toString()
              ) {
                delete bundle[key]
              } else {
                fileContents.set(key, currentFile.source)
              }
            }
          }
        }

        isInit = false
      }
    },
  }
}
